feat(cli): share the platform-binary resolver, add the Vale binary spec - #71
feat(cli): share the platform-binary resolver, add the Vale binary spec#71thecodedrift wants to merge 17 commits into
Conversation
12dded7 to
c452427
Compare
A spec-only proposal is its own tip until its implementation is stacked on top, so the gate demanded it archive a change nobody had built yet. #70, #71, and #72 have failed this check on every run since July 28 for exactly that reason — weeks of red on PRs that were never merge-eligible, which is how a team learns to stop reading red. A draft cannot merge, so skipping it costs nothing: no unarchived change can reach `main` either way. `ready_for_review` is added to the trigger types because the default set (opened/synchronize/reopened) does not include it — without that, a draft could be marked ready and merged on a green that was never re-evaluated. That event is what preserves the guarantee. The tip rule is unchanged for PRs that are ready for review.
c452427 to
7b4d143
Compare
Completes tasks 6.1-6.3 of add-vale-binary-packages, which were blocked until
the packages existed on npm. All six are now published at
3.17.1-20260810052605, so the CLI pins that exact version.
The pin is literal, per D8, and that needed checking rather than assuming:
`packages/*` makes the six vale packages workspace members at 0.0.0, so pnpm
could have linked them locally and silently reproduced the `workspace:*`
behaviour D8 rejects. The lockfile records a registry resolution and
node_modules points into the store at the pinned version, so the literal pin
means what it says.
6.2 verified end to end: createRequire from the CLI resolves the package, the
sibling `vale` is mode 755 and reports `vale version 3.17.1`, and only the
host-matching package installs — the other five are filtered by os/cpu.
6.3 verified against two real published stamps rather than by argument: with
dist-tags.latest at 3.17.1-20260810052605, a project pinned to
3.17.1-20260810045147 installs 045147. `^3.17.1` matches no version at all, so
a range cannot float into a prerelease.
Adds the changeset the proposal calls for ("Release impact: minor"). This is
why `skip-changeset` no longer applies to this PR: it was accurate while the
change was docs-only, and the pin makes it a real release — installing the CLI
now also delivers a binary.
7.3 stays open here by necessity; it edits add-vale-rule-engine's tasks, which
exist only on #71.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Completes tasks 6.1-6.3 of add-vale-binary-packages, which were blocked until
the packages existed on npm. All six are now published at
3.17.1-20260810052605, so the CLI pins that exact version.
The pin is literal, per D8, and that needed checking rather than assuming:
`packages/*` makes the six vale packages workspace members at 0.0.0, so pnpm
could have linked them locally and silently reproduced the `workspace:*`
behaviour D8 rejects. The lockfile records a registry resolution and
node_modules points into the store at the pinned version, so the literal pin
means what it says.
6.2 verified end to end: createRequire from the CLI resolves the package, the
sibling `vale` is mode 755 and reports `vale version 3.17.1`, and only the
host-matching package installs — the other five are filtered by os/cpu.
6.3 verified against two real published stamps rather than by argument: with
dist-tags.latest at 3.17.1-20260810052605, a project pinned to
3.17.1-20260810045147 installs 045147. `^3.17.1` matches no version at all, so
a range cannot float into a prerelease.
Adds the changeset the proposal calls for ("Release impact: minor"). This is
why `skip-changeset` no longer applies to this PR: it was accurate while the
change was docs-only, and the pin makes it a real release — installing the CLI
now also delivers a binary.
7.3 stays open here by necessity; it edits add-vale-rule-engine's tasks, which
exist only on #71.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Completes tasks 6.1-6.3 of add-vale-binary-packages, which were blocked until
the packages existed on npm. All six are now published at
3.17.1-20260810052605, so the CLI pins that exact version.
The pin is literal, per D8, and that needed checking rather than assuming:
`packages/*` makes the six vale packages workspace members at 0.0.0, so pnpm
could have linked them locally and silently reproduced the `workspace:*`
behaviour D8 rejects. The lockfile records a registry resolution and
node_modules points into the store at the pinned version, so the literal pin
means what it says.
6.2 verified end to end: createRequire from the CLI resolves the package, the
sibling `vale` is mode 755 and reports `vale version 3.17.1`, and only the
host-matching package installs — the other five are filtered by os/cpu.
6.3 verified against two real published stamps rather than by argument: with
dist-tags.latest at 3.17.1-20260810052605, a project pinned to
3.17.1-20260810045147 installs 045147. `^3.17.1` matches no version at all, so
a range cannot float into a prerelease.
Adds the changeset the proposal calls for ("Release impact: minor"). This is
why `skip-changeset` no longer applies to this PR: it was accurate while the
change was docs-only, and the pin makes it a real release — installing the CLI
now also delivers a binary.
7.3 stays open here by necessity; it edits add-vale-rule-engine's tasks, which
exist only on #71.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
There was a problem hiding this comment.
Pull request overview
Adds a new OpenSpec change (openspec/changes/add-vale-rule-engine/) that specifies how the CLI should support Vale as a second static-tier rules engine, including engine routing, concurrent execution/merge semantics, and an engine-selection help topic.
Changes:
- Introduces requirements for running Vale with committed config, mapping Vale JSON findings into
CheckResult, and handling missing binary/timeout cases. - Specifies per-rule scoping via
.vale.inimatchers and fixture-based Vale rule verification (pass//fail). - Adds proposal/design/tasks documentation and OpenSpec metadata for the change.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| openspec/changes/add-vale-rule-engine/.openspec.yaml | Declares OpenSpec change metadata (schema + created date). |
| openspec/changes/add-vale-rule-engine/proposal.md | Summarizes motivation, scope, impact, and delivery shape for adding Vale + engine-selection topic. |
| openspec/changes/add-vale-rule-engine/design.md | Captures key design decisions (scoping, layout, binary resolution strategy, mapping contract). |
| openspec/changes/add-vale-rule-engine/tasks.md | Implementation task checklist for Vale engine execution, orchestration, and help topic registration/tests. |
| openspec/changes/add-vale-rule-engine/specs/cli-vale-rule-engine/spec.md | Requirements for Vale execution model, config usage, result mapping, timeout, missing-binary behavior, and verify fixtures. |
| openspec/changes/add-vale-rule-engine/specs/cli-rule-routing/spec.md | Requirements for the engine-selection knowledge topic and its constraints (distinct from route/trust tier). |
| openspec/changes/add-vale-rule-engine/specs/cli-rule-format/spec.md | Requirements for Vale style layout under .taskless/vale/rules/ and rules. prefix normalization. |
| openspec/changes/add-vale-rule-engine/specs/cli-help/spec.md | Requirements to register engine-selection in help + add cross-references from route/static. |
| openspec/changes/add-vale-rule-engine/specs/cli-check/spec.md | Requirements for concurrent per-engine execution and merged results semantics. |
Suppressed comments (1)
openspec/changes/add-vale-rule-engine/specs/cli-check/spec.md:18
- This spec ends with a "## MODIFIED Requirements" heading but contains no modified requirements. Empty sections can confuse reviewers and can break tooling that expects content under each header.
## MODIFIED Requirements
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
16f518f to
c69649c
Compare
c69649c to
32dce95
Compare
|
@claude /review |
|
Claude finished @thecodedrift's task in 2m 1s —— View job Review checklist
Overall assessmentThis is careful, well-documented work — the doc comments explaining why (toolchainSuffix, identity checks, the Bug (blocking): Real regressions from the Design gap worth a second look: in Smaller correctness concern: Process note: the changeset ( Also flagged inline, lower priority: Not flagged as blocking, but noted during review for awareness: Copilot's earlier review comments about the two |
…required Two gaps in the "One changeset, at the bottom of the stack" guidance, both found on the #71/#93/#94/#95 merging-down stack: - The section said where the changeset lives but never when to create it. Document the trigger: write it on the base branch before cutting the children, so every child inherits a file it can append to. - "Grow it incrementally" is merge-forward reasoning. Under merging down nothing lands until everything does, so a whole-change note is accurate at the only moment it is read. Scope the rule to forward, and state the constraint that holds in both shapes: the file must be in the bottom PR's own diff. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
90c8e50 to
6422854
Compare
Every finding here was verified against the source before changing anything, and all of them held. Addressed in
Declined, with reasons in-thread: the changeset scope (the Not actioned, but recorded as follow-ups: — AI Coding Agent |
…ngine Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LdEhGzeQfSGJM3nKKNj7Bp
The ingest writer hardcodes .taskless/rules, so 0004 would relayout existing rules under sg/ while the next `rule create` wrote back into a directory no engine dispatches from. The API carries no engine discriminator, so an engine-less payload is ast-grep by definition — the default is permanent, matching what the migration does to the same rules on disk. An unrecognized engine fails loudly instead, since filing a Vale rule under sg/ surfaces as a broken rule rather than version skew. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
…solution Three additions to the Vale change: The engine-selection knowledge topic ships here rather than as a follow-on, since it names the engine directories this change creates. Written engine-shaped, kept distinct from route's authoring destination and from trust tier — sg and vale are both static-tier, and conflating those axes is what the platform's binary classifier got wrong. Legacy .taskless/rules/ stays dispatchable alongside sg/rules/, so a producer can keep using the pre-migration layout and its rules keep running. That decouples this release from any consumer's. Vale's binary resolves the way findSgBinary already does — resolve the platform package from our own module context and exec beside it, no lifecycle script. That code exists because the upstream postinstall hardlink breaks under pnpm dlx, leaving a placeholder text file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
An OpenSpec change has to land complete in one PR — the archive gate requires a tip PR to leave openspec/changes/ empty — and this one had grown to 44 tasks across ten groups. The layout half moved to partition-rules-by-engine. What stays is the engine and the knowledge about it: the Vale runner and its scoping model, fixture verify, findings mapping, concurrent multi-engine orchestration, and the engine-selection topic. 22 tasks. The topic stays here rather than with the layout because it names sg/vale/runtime as things an agent can actually choose between, which is only true once Vale executes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
Every one of these PRs carried skip-changeset while it was spec-only, which becomes wrong the moment implementation lands. Stating the impact in the proposal means the tip PR needs a changeset written, not a label kept. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
…s ship add-vale-binary-packages published all six @taskless/vale-<os>-<cpu> packages and pinned them as optionalDependencies, so tasks 1.1b-1.1e (publishing, the mirror CI job, the musl decision, and the Vite externalization check) are done elsewhere. Section 1 reduces to the runtime resolution in task 1.1, which now carries the one detail the removed subtasks held: the Vale packages have no libc suffix, so the shared helper must not apply findSgBinary()'s -gnu mapping to them. Closes task 7.3 of add-vale-binary-packages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
…ed path 0.1 and 0.2 both hold now: partition-rules-by-engine is archived on main as 2026-08-02-partition-rules-by-engine, and all six @taskless/vale-* packages are published at 3.17.1-20260810052605 and pinned in packages/cli optionalDependencies. Record what satisfies them rather than leaving the boxes open for someone to re-derive. Task 3.1 sourced the topic from tmp/SEED-engine-selection-prose.md, which no longer exists. Treating a missing file as a prerequisite would block the unit on recovering it; the content requirements are already stated in 3.1-3.4, so name those as the authority and rebuild the examples. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
…ng ref
tasks.md deferred the export decision to "D9", which does not exist — the
design records D1-D7 and the reasoning lives in D7. Point at D7.
The conditional it carried ("whichever of this change and
export-knowledge-prompts lands second adds the entry") has resolved:
export-knowledge-prompts landed first, so the entry falls here. Restate it as
fact and add task 3.7 for it.
3.7 is not bookkeeping. TOPICS union INTERNAL_TOPICS must account for every
canonical recipe on disk, and test/prompts.test.ts asserts that in both
directions, so adding help/<engine-selection>.txt without classifying it turns
the suite red. The registration therefore belongs in the same unit as the topic
file rather than a later one.
D7 now records why TOPICS rather than INTERNAL_TOPICS, since the difference is
functional rather than a label: TOPICS generates the exported PromptTopic union,
the argument getPrompt() accepts, and the keys of the published PROMPTS record,
so an entry there is what makes the topic renderable outside this package.
INTERNAL_TOPICS has no runtime effect — it is a ledger for the completeness
check. Neither affects `taskless help`. The consumer is the reason: engine
selection currently lives in the platform generator as a binary static|runtime
classifier that predates Vale, and exporting the entry is what lets the
generator render the same text instead of drifting from it. The accepted cost is
that an exported name is a promise held for a major version.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Task 1.1. `findSgBinary()` hard-coded ast-grep's packaging: the `@ast-grep/cli`
prefix, the `-gnu`/`-msvc` suffixes, the two bin spellings, and an `ast-grep`
identity check. Vale needs the same search with different answers to all four,
so the search moves to `rules/platform-binary.ts` and each engine supplies a
spec.
The parameter that matters is `toolchainSuffix`. ast-grep publishes
`@ast-grep/cli-linux-x64-gnu`; add-vale-binary-packages publishes
`@taskless/vale-linux-x64` with no libc suffix at all. Reusing ast-grep's naming
for Vale would resolve nothing on Linux and surface as the ordinary "Vale is
unavailable" message — a naming bug wearing the costume of a host that never
installed it. test/platform-binary.test.ts pins the naming for both engines
across every published platform, and cross-checks the Vale names against the
optionalDependencies actually declared in package.json so a rename on either
side fails there rather than at runtime.
The resolver returns `{path, tried}` instead of throwing, because the two
callers need different things from a miss: ast-grep is the only executor for
`sg` rules, so `findSgBinary()` keeps throwing, while `findValeBinary()` returns
undefined per D6b — a missing Vale binary makes one engine unavailable and must
not abort the others. `findValeBinary()` caches the miss as well as the hit,
since an absent Vale is the common case and each resolution spawns a subprocess
per candidate.
ast-grep behaviour is unchanged: `isAstGrepBinary` and `findSgBinary` keep their
signatures and their existing tests, all 456 green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Tasks 1.2 and 1.3. `rules/vale/run.ts` invokes the resolved binary with
`--config .taskless/vale/.vale.ini --output=JSON --no-exit`, and
`rules/vale/map.ts` turns what comes back into CheckResults.
`--no-exit` is what makes the exit code readable: without it Vale exits non-zero
merely for having found something, which is indistinguishable from failing to
run. With it, non-zero means Vale itself failed.
The result is an outcome union rather than "results or throw". Three of its four
cases — unavailable, timeout, failed — are things the caller reports and keeps
going from, and under D6b the orchestration layer has to tell "Vale found
nothing" from "Vale never ran", a distinction an empty array erases.
Mapping decisions worth naming:
- `rules.` is stripped from the check name. The style directory is called
`rules`, so Vale reports `rules.no-simply` for what a user wrote as
`no-simply`; the prefix is an artifact of config layout, not part of the id.
- An unrecognized severity becomes `warning` rather than being dropped. A
finding whose level we cannot read is still a finding.
- `fix` comes only from a `replace` action's replacement. Vale's other actions
describe an edit whose result is not in the payload, so putting the action
name there would offer to replace the user's match with the word "remove".
The JSON shape was captured from the real binary rather than assumed —
`Action: {Name: "", Params: null}`, `Span`, `Line`, capitalized keys — and the
tests run the real Vale rather than a stub, since what is being asserted is what
Vale does with a config. It ships as an optionalDependency for every platform we
publish, so those cases run in CI too; they skip only on an unsupported arch,
where a stub would be asserting our own mock.
Covers the non-verify half of 1.5: stripping and severity mapping, all three
scoping cases (include, exclude-wins, duplicate matchers merge), missing-binary,
and timeout. The verify half needs 1.4 and lands in unit 2; 1.5 is annotated
rather than ticked so that stays visible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
…g shape
Two review points from unit 1.
`isValeFailure()` names which outcomes fail the check. The three non-ok cases
are not equivalent and collapsing them is wrong in both directions:
`unavailable` is a skip — an unsupported arch is an ordinary state, not evidence
the user's rules are wrong, and failing there makes `check` unrunnable on a
machine where ast-grep and runtime rules report fine. `timeout` and `failed` are
errors: Vale was present and asked to work, and reporting those as a skip lets a
broken rule file read as "no Vale findings", which is indistinguishable from a
clean run. Exported so orchestration (2.2) derives the exit code from one rule
rather than restating it.
The unknown-severity branch is future-proofing, now labelled as such: Vale
enforces its own vocabulary, rejecting anything outside
[suggestion warning error], so nothing reaches the default today.
Establishing that turned up Vale's config-error payload — a flat
{Line, Path, Text, Code, Span} object rather than findings-keyed-by-file.
Measured against the real binary: it goes to stderr with exit 2 and an empty
stdout, so the existing non-zero-exit branch already reports it and the shape
never reaches the mapper. (An earlier reading of "exit 0" was an artifact of
piping through `head`, which reports the pipe's status, with 2>&1 making stderr
look like stdout.)
The guard is kept as defence rather than a fix, because the failure mode if a
future Vale reports config errors on stdout is not a wrong answer but a crash:
mapping walks Object.entries over Line/Path/Code and calls .map on a number, and
an uncaught throw out of runVale would take the other engines with it (D6b).
Both the type guard and an Array.isArray check in the mapper are covered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Vale is a vendored binary upgraded on its cadence, not ours, so the assumptions
our mapping rests on are worth asserting directly. vale-vendor-contract.test.ts
invokes Vale rather than runVale — a test through our wrapper would assert our
interpretation, which is what everything else already covers — and each case
names the code that breaks if the behaviour changes.
Writing it found three things that were wrong, two of them mine.
MATCHER PRECEDENCE. The spec said a disable "takes precedence over an enable,
independent of order". Measured against 3.17.1 that is false in both
directions. Precedence is positional: where two matchers both match a file the
LAST wins, and where one matcher assigns the same key twice — including across
duplicate [glob] sections, which Vale merges — the FIRST wins. So
[marketing/**] YES then [marketing/legacy/**] NO excludes legacy, and the same
two matchers in the opposite order do not. The spec now states that, requires a
disable to be declared after the enable it narrows, and warns that a repeated
assignment inside one glob is discarded — which matters for tooling that
appends to an existing matcher.
The unit-1 test covering this was named "lets a disable win over an enable,
regardless of order" and exercised one order. It passed while the claim it
named was false. Renamed to what it actually asserts, with the opposite order
pinned in the contract file.
EXIT CODES. Vale's exit code keys off severity, not off having found anything:
suggestion and warning exit 0 even without --no-exit, only error exits 1. So
--no-exit is load-bearing exactly for error-level rules, which is where dropping
it would hurt most — every check with a real violation reported as a failed
engine instead of as findings.
EMPTY RESULTS. Vale prints `{}`, not nothing, when it finds nothing. runVale's
empty-stdout branch is therefore insurance rather than the live path, and the
comment claiming otherwise is corrected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
`require-changeset` only ever evaluates the bottom PR's own diff, so a changeset written on the tip is invisible to the PR that actually merges — removing `skip-changeset` from this PR turned the check red with the note sitting three branches above it. The stack merges down, so nothing lands until everything does and one note describing the whole change is accurate at the only moment it is read. It is moved here rather than duplicated; the tip's copy goes away as the units restack onto this commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
…rch trail Extracting the shared resolver quietly reversed ast-grep's original ordering: it tried `sg` before `ast-grep` at both link-based tiers, with a comment marking that deliberate, and iterating `binaryNames` in list order flipped it. Nothing observable rides on it — both names link to the same target and `isPlatformBinary` verifies whichever answers — which is exactly why no test caught it. `resolvePlatformBinary` now reverses the list at the link tiers, and a new case pins the order. The platform-package tier is back to probing one name. Probing every spelling there cost an extra `require.resolve` and listed the package label twice in `tried`, so "Looked in: ..." read as if we had searched the same place twice; `tried` now names each location once. `findSgBinary`'s PATH advice is spelled for the platform again, via `pathCommandName`, rather than hardcoding `sg` — a Windows user was being told to install a name we would not have looked for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Four corrections to the Vale engine: `toValeCheckResult` wrote Vale's 1-based `Line` and `Span` into `CheckResult.range` unconverted. `range` is 0-indexed everywhere else — ast-grep's native range passes straight through, the runtime harness converts its 1-based `Finding` down, and `format.ts` adds 1 back for every source at display time — so every Vale finding was reported one line and one column further into the file than it is. Both now convert down, clamped at 0. The vendor-contract test still pins Vale's raw 1-based output, which is correct as it stands. stdout and stderr were accumulated with `chunk.toString()` per Buffer, which replaces each half of a multi-byte UTF-8 sequence split across a chunk boundary with U+FFFD. Vale lints prose full of curly quotes and em dashes, and corruption landing inside JSON string escaping makes `JSON.parse` throw, reporting a clean run as `failed`. Each stream now goes through a `StringDecoder`, flushed on close. A spawn `error` resolved as `unavailable`, an advisory skip. But `findValeBinary` proved the binary runs by executing `--version` during resolution, so an error on the scan spawn means it vanished, lost permissions, or was quarantined in between — a real failure, as `runAstGrepScan` treats the same event. Reclassified to `failed`. `run.ts` claimed a config error arrives on stdout with exit 0; measurement against the real binary says stderr with exit 2, as `map.ts` already documented. The comment now says so and describes the guard as defensive. Blocking-ness moves onto `ValeRunOutcome` as a literal-typed `blocking` field, replacing the `isValeFailure` free function. Severity is a property of what happened, not knowledge each caller must remember to apply, and every engine we add should return the same self-describing shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
6422854 to
d6ddbeb
Compare
thecodedrift
left a comment
There was a problem hiding this comment.
Small comments, nothing blocking. Wanted to explore how to stay as efficient as possible in the "check" flow because that's the one people will run via CI setups often hundreds of times a day. Anywhere we can be more efficient on this codepath is a small win.
| @@ -0,0 +1,213 @@ | |||
| import { spawnSync } from "node:child_process"; | |||
There was a problem hiding this comment.
We should really prefer async alternatives to sync options when possible
| import { existsSync } from "node:fs"; | ||
| import { createRequire } from "node:module"; | ||
| import { spawn, spawnSync } from "node:child_process"; | ||
| import { spawn } from "node:child_process"; |
There was a problem hiding this comment.
Do we gain anything from switching to execa here? It's a little more feature complete, but I'd like to see if there are tradeoffs worth it WRT stdout/err capture
Stack (root → tip):
Unit 1 of
add-vale-rule-engine— the bottom of a four-unit stack that merges down. This PR carries the change proposal plus unit 1's implementation.Stack
openspec/add-vale-rule-engine← this PRCheckResultmapping…-2-verify…-3-orchestration…-4-topicTOPICSentry, archiveMerging down, per the proposal: the Vale engine is only correct once
checkcan dispatch to it — the runner without orchestration ships an engine that never executes, and orchestration without the runner dispatches to nothing. Units merge tip→root, then one protected merge of this branch tomain.Landed so far — task 1.1
findSgBinary()hard-coded ast-grep's packaging: the@ast-grep/cliprefix, the-gnu/-msvcsuffixes, two bin spellings, and anast-grepidentity check. Vale needs the same search with different answers to all four, so the search moved torules/platform-binary.tsand each engine supplies a spec.The parameter that matters is
toolchainSuffix. ast-grep publishes@ast-grep/cli-linux-x64-gnu;add-vale-binary-packagespublishes@taskless/vale-linux-x64with no libc suffix. Reusing ast-grep's naming for Vale would resolve nothing on Linux and surface as the ordinary "Vale is unavailable" message — a naming bug wearing the costume of a host that never installed it.test/platform-binary.test.tspins the naming for both engines across every published platform and cross-checks the Vale names against theoptionalDependenciesactually declared, so a rename on either side fails there rather than at runtime.The resolver returns
{path, tried}rather than throwing, because the callers need different things from a miss. ast-grep is the only executor forsgrules, sofindSgBinary()still throws;findValeBinary()returns undefined per D6b — a missing Vale binary makes one engine unavailable and must not abort the others. It caches the miss as well as the hit, since an absent Vale is the common case and each resolution spawns a subprocess per candidate.ast-grep behaviour is unchanged:
isAstGrepBinaryandfindSgBinarykeep their signatures and their existing tests.Still to come in this unit
rules/vale/run.ts— invoke Vale against the committed config with JSON output, bounded by a subprocess timeoutCheckResult:rules.prefix stripping, severity normalization (suggestion → hint), range fromLine/SpanVerification
pnpm --filter @taskless/cli test→ 456 passed; typecheck, lint, prettier clean.Refs OSS-21